--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 17e20e7724b25d4e2fc9b2195a36ff57b6a75189
Parents : 407b651
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-22T17:31:15-05:00
feat(electron): add in-window navigation guard to restrict external URL navigation and improve security
Changes
9 files changed, 222 insertions(+), 11 deletions(-)
Diff
diff --git a/electron/main.js b/electron/main.js
index aae18005..c2b6d50f 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -22,6 +22,7 @@ const {
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
+ shouldAllowInWindowNavigation,
} = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
@@ -370,6 +371,19 @@ function attachWindowOpenHandler(browserWindow) {
browserWindow.webContents.setWindowOpenHandler(({ url }) => handleWindowOpenRequest(url));
}
+function attachInWindowNavigationGuard(browserWindow) {
+ browserWindow.webContents.on("will-navigate", (event, url) => {
+ if (shouldAllowInWindowNavigation(url)) {
+ return;
+ }
+ event.preventDefault();
+ const safe = normalizeExternalUrlForOpen(url);
+ if (safe) {
+ shell.openExternal(safe);
+ }
+ });
+}
+
function attachDevToolsF12Shortcut(browserWindow) {
browserWindow.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.key !== "F12") {
@@ -610,6 +624,7 @@ app.whenReady().then(async () => {
attachDefaultContextMenu(browserWindow);
attachDevToolsF12Shortcut(browserWindow);
attachWindowOpenHandler(browserWindow);
+ attachInWindowNavigationGuard(browserWindow);
});
// Security: Enforce CSP for all requests as a shell-level fallback
diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js
index 0c76c0cb..2064268a 100644
--- a/electron/mainHelpers.js
+++ b/electron/mainHelpers.js
@@ -69,9 +69,26 @@ function shouldOpenInElectronWindow(url) {
return url.includes("#/popout/");
}
+/**
+ * Whether the main frame may navigate to this URL inside Electron (local app shell).
+ * External http(s) links must open in the system browser instead.
+ * @param {unknown} url
+ * @returns {boolean}
+ */
+function shouldAllowInWindowNavigation(url) {
+ if (!url || typeof url !== "string") {
+ return false;
+ }
+ if (url.startsWith("blob:")) {
+ return true;
+ }
+ return isLocalBackendUrl(url);
+}
+
module.exports = {
getUserProvidedArguments,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
+ shouldAllowInWindowNavigation,
};
diff --git a/meshchatx/__init__.py b/meshchatx/__init__.py
index c6948501..7d7d9bf9 100644
--- a/meshchatx/__init__.py
+++ b/meshchatx/__init__.py
@@ -3,4 +3,4 @@
"""Reticulum MeshChatX - A mesh network communications app."""
# Synced from package.json via scripts/sync_version.js (also writes meshchatx/src/version.py).
-__version__ = "4.7.2"
\ No newline at end of file
+__version__ = "4.7.2"
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 55f3223f..39bd9b55 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -10999,7 +10999,13 @@ class ReticulumMeshChat:
node = self.page_node_manager.get_node(node_id)
if not node:
return web.json_response({"message": "Node not found"}, status=404)
- data = await request.json()
+ try:
+ data = await request.json()
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Invalid request body: {e}"},
+ status=400,
+ )
name = data.get("name", "")
content = data.get("content", "")
if not name:
@@ -11011,6 +11017,16 @@ class ReticulumMeshChat:
saved_name = node.add_page(name, content)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
+ except OSError as e:
+ return web.json_response(
+ {"message": f"Failed to write page: {e}"},
+ status=500,
+ )
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Failed to save page: {e}"},
+ status=500,
+ )
return web.json_response({"name": saved_name, "message": "Page saved"})
@routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}")
diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
index fd367020..058e6897 100644
--- a/meshchatx/src/backend/page_node.py
+++ b/meshchatx/src/backend/page_node.py
@@ -314,7 +314,12 @@ class PageNode:
with open(page_path, "wb") as f:
f.write(content)
if self.running:
- self._register_page_handler(name)
+ try:
+ self._register_page_handler(name)
+ except Exception as e:
+ raise RuntimeError(
+ f"Page written but failed to register on mesh: {e}"
+ ) from e
return name
def remove_page(self, name):
diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index e5919ab8..a535bc4b 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -162,6 +162,7 @@
<div
ref="previewRef"
class="flex-1 overflow-auto text-zinc-100 p-4 font-mono text-sm whitespace-pre-wrap wrap-break-word nodeContainer"
+ @click="onPreviewClick"
v-html="renderedContent"
></div>
<!-- eslint-enable vue/no-v-html -->
@@ -176,8 +177,12 @@ import MicronParser from "../../js/MicronParser.js";
import { micronStorage } from "../../js/MicronStorage";
import { preloadNomadMicronWasm, isMicronWasmBundled } from "../../js/MicronWasmLoader";
import DialogUtils from "../../js/DialogUtils";
+import LinkUtils from "../../js/LinkUtils.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
+const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/;
+const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"];
+
export default {
name: "MicronEditorPage",
components: {
@@ -227,6 +232,101 @@ export default {
this.renderActiveTab();
this.saveContent();
},
+ openExternalHttpUrl(url) {
+ if (!url) {
+ return;
+ }
+ window.open(url, "_blank", "noopener,noreferrer");
+ },
+ openNomadDestination(destination) {
+ const raw = String(destination || "")
+ .trim()
+ .replace(/^nomadnetwork:\/\//i, "")
+ .replace(/^lxmf:\/\//i, "");
+ if (!raw) {
+ return;
+ }
+ const httpHref = LinkUtils.httpUrlHrefOrNull(raw);
+ if (httpHref) {
+ this.openExternalHttpUrl(httpHref);
+ return;
+ }
+ const [hash, ...pathParts] = raw.split(":");
+ if (!NOMAD_DESTINATION_HASH.test(hash)) {
+ return;
+ }
+ const pathPart = pathParts.join(":") || "/page/index.mu";
+ const pagePath = pathPart.split("`")[0].split("?")[0];
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: pagePath },
+ });
+ },
+ onPreviewClick(event) {
+ const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
+ if (nomadLink) {
+ event.preventDefault();
+ event.stopPropagation();
+ const url = nomadLink.getAttribute("data-nomadnet-url");
+ if (url) {
+ this.openNomadDestination(url);
+ }
+ return;
+ }
+
+ const externalAnchor = event.target.closest("a[href]");
+ if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
+ const href = externalAnchor.getAttribute("href");
+ const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
+ if (httpHref) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.openExternalHttpUrl(httpHref);
+ return;
+ }
+ }
+
+ const fragAnchor = event.target.closest("a[href]");
+ if (
+ fragAnchor &&
+ fragAnchor.getAttribute("href") &&
+ fragAnchor.getAttribute("href") !== "#" &&
+ fragAnchor.getAttribute("href").startsWith("#") &&
+ !fragAnchor.getAttribute("data-nomadnet-url")
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const raw = fragAnchor.getAttribute("href").slice(1);
+ const id = decodeURIComponent(raw);
+ const root = this.$refs.previewRef;
+ const el = root ? root.querySelector(`#${CSS.escape(id)}`) : document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ return;
+ }
+
+ const nodeLink = event.target.closest('[data-action="openNode"]');
+ if (nodeLink) {
+ event.preventDefault();
+ event.stopPropagation();
+ const destination = nodeLink.getAttribute("data-destination");
+ if (destination) {
+ this.openNomadDestination(destination);
+ }
+ return;
+ }
+
+ const anchor = event.target.closest("a[href]");
+ if (anchor) {
+ const href = (anchor.getAttribute("href") || "").trim();
+ if (href && href !== "#" && !href.startsWith("#")) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ },
renderActiveTab() {
if (this.tabs.length === 0 || !this.tabs[this.activeTabIndex]) {
this.renderedContent = "";
@@ -958,11 +1058,34 @@ ${b}=
},
tabNameToPageBase(tab) {
let name = (tab.name || "").trim().replace(/\s+/g, "_");
- if (name.toLowerCase().endsWith(".mu")) {
- name = name.slice(0, -3);
+ const lower = name.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return name.slice(0, -ext.length);
+ }
}
return name;
},
+ pageBaseWithExtension(base, tab) {
+ const trimmed = String(base || "").trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+ const lower = trimmed.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return trimmed;
+ }
+ }
+ const tabName = (tab?.name || "").trim();
+ const tabLower = tabName.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (tabLower.endsWith(ext)) {
+ return `${trimmed}${ext}`;
+ }
+ }
+ return trimmed;
+ },
isUnsetMicronTabName(name) {
const trimmed = (name || "").trim();
if (!trimmed) {
@@ -994,8 +1117,11 @@ ${b}=
return null;
}
let base = String(entered).trim().replace(/\s+/g, "_");
- if (base.toLowerCase().endsWith(".mu")) {
- base = base.slice(0, -3);
+ const lower = base.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return base;
+ }
}
return base || null;
},
@@ -1007,12 +1133,13 @@ ${b}=
if (!pageBase) {
return;
}
+ const publishName = this.pageBaseWithExtension(pageBase, tab);
const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
- name: pageBase,
+ name: publishName,
content: tab.content,
});
this.showPublishMenu = false;
- const savedName = response.data?.name || `${pageBase}.mu`;
+ const savedName = response.data?.name || publishName;
DialogUtils.alert(
this.$t("tools.micron_editor.publish_published", { page: savedName, server: node.name })
);
@@ -1042,9 +1169,10 @@ ${b}=
if (!pageBase) {
continue;
}
+ const publishName = this.pageBaseWithExtension(pageBase, tab);
try {
const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
- name: pageBase,
+ name: publishName,
content: tab.content,
});
const savedName = response.data?.name;
diff --git a/tests/backend/test_telemetry_integration.py b/tests/backend/test_telemetry_integration.py
index 35964252..e1efc27e 100644
--- a/tests/backend/test_telemetry_integration.py
+++ b/tests/backend/test_telemetry_integration.py
@@ -43,7 +43,9 @@ def mock_app():
ReticulumMeshChat.process_incoming_telemetry.__get__(app, ReticulumMeshChat)
)
app._resolve_location_for_telemetry = (
- ReticulumMeshChat._resolve_location_for_telemetry.__get__(app, ReticulumMeshChat)
+ ReticulumMeshChat._resolve_location_for_telemetry.__get__(
+ app, ReticulumMeshChat
+ )
)
return app
diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js
index deb6d67f..aaa7f46a 100644
--- a/tests/electron/mainHelpers.test.js
+++ b/tests/electron/mainHelpers.test.js
@@ -42,4 +42,13 @@ describe("electron/mainHelpers", () => {
expect(shouldOpenInElectronWindow("https://example.com/#/popout/map")).toBe(false);
expect(shouldOpenInElectronWindow("")).toBe(false);
});
+
+ it("shouldAllowInWindowNavigation keeps local backend URLs in Electron", () => {
+ const { shouldAllowInWindowNavigation } = require("../../electron/mainHelpers.js");
+ expect(shouldAllowInWindowNavigation("https://127.0.0.1:9337/#/tools/micron-editor")).toBe(true);
+ expect(shouldAllowInWindowNavigation("http://localhost:9337/")).toBe(true);
+ expect(shouldAllowInWindowNavigation("blob:https://127.0.0.1:9337/print")).toBe(true);
+ expect(shouldAllowInWindowNavigation("https://example.com/")).toBe(false);
+ expect(shouldAllowInWindowNavigation("file:///etc/passwd")).toBe(false);
+ });
});
diff --git a/tests/frontend/MicronEditorPage.test.js b/tests/frontend/MicronEditorPage.test.js
index b5714f78..f8cc40e2 100644
--- a/tests/frontend/MicronEditorPage.test.js
+++ b/tests/frontend/MicronEditorPage.test.js
@@ -146,6 +146,25 @@ describe("MicronEditorPage.vue", () => {
});
});
+ it("pageBaseWithExtension preserves html tab extension when publishing", async () => {
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
+ const tab = { name: "Landing.html", content: "<p>hi</p>" };
+ expect(wrapper.vm.pageBaseWithExtension("landing", tab)).toBe("landing.html");
+ });
+
+ it("onPreviewClick opens http links externally", async () => {
+ const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
+ const preview = wrapper.find(".nodeContainer");
+ preview.element.innerHTML = '<a href="https://example.com/page.html">Example</a>';
+ const link = preview.find("a");
+ await link.trigger("click");
+ expect(openSpy).toHaveBeenCalledWith("https://example.com/page.html", "_blank", "noopener,noreferrer");
+ openSpy.mockRestore();
+ });
+
it("resets all content", async () => {
DialogUtils.confirm.mockResolvedValue(true);
const wrapper = mountMicronEditorPage();
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────